今天是Dagger的最後一篇,會處理ViewModel的部分。
由於ViewModle需透過ViewModelProviders來實例化而不是直接呼叫constructor,所以在DI的操作上會不太相同,我們會使用Multibindings的方式來封裝ViewModelFactory。
這樣有什麼好處?目前的程式,要新增ViewModel的話須在ViewModelFactory增加判斷,例如要新增UserViewModel:
@Singleton
public class GithubViewModelFactory implements ViewModelProvider.Factory {
    ...
    public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
        if (modelClass.isAssignableFrom(RepoViewModel.class)) {
            return (T) new RepoViewModel(dataModel);
        } else if (modelClass.isAssignableFrom(UserViewModel.class)) {
            return (T) new UserViewModel(dataModel);
        }
        throw new IllegalArgumentException("Unknown ViewModel class");
    }
}
除了每次都要寫else-if之外,如果新的ViewModel其constructor parameter不同,就會連ViewModelFactory的其他地方也要修改。
那麼,使用Multibindings建立ViewModelModule來管理ViewModel的話,可以讓我們將來新增ViewModel時只要在Module中加上一行,不用再修改ViewModelFactory。
概念是使用Multibindings產生一個Map<Key, Provider<Value>>來告訴ViewModelFactory要建立哪個ViewModel,令Key為ViewModel的class,而Value就是ViewModel本身,例如Map<RepoViewModel.class, Provider<RepoViewModel>>,那就開始吧。
首先建立ViewModelKey:
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@MapKey
@interface ViewModelKey {
    Class<? extends ViewModel> value();
}
使用@MapKey標註並讓value()的型態為繼承ViewModel的Class。
建立ViewModelModule:
@Module
abstract class ViewModelModule {
    @Binds
    @IntoMap
    @ViewModelKey(RepoViewModel.class)
    abstract ViewModel bindRepoViewModel(RepoViewModel repoViewModel);
    @Binds
    abstract ViewModelProvider.Factory bindViewModelFactory(GithubViewModelFactory factory);
}
@IntoMap會產生一個Map<Key, Provider<Value>>,以此處而言Key是@ViewModelKey的RepoViewModel.class,而Value為@Binds的parameter即RepoViewModel。至於Provider我們待會再說。
其中@Binds是一種簡化的寫法,用@Provides的話必須這樣寫:
@Module
class AppModule {
    @Provides
    ViewModel provideRepoViewModel(DataModel dataModel) {
        return new RepoViewModel(dataModel);
    }
}
因為RepoViewModel的建立方式很簡單,只是用new把原本就在Dagger管理下的DataModel當作constructor parameter,節錄官方FAQ的說明:
whenever there is a @Provides whose implementation is simple and common enough to be inferred by Dagger, it makes sense to just declare that as a method without a body (an abstract method) and have Dagger apply the behavior.
當物件的建立方式只是用new呼叫constructor的時候,可以用@Binds來取代@Provides,完整說明再點連結去看囉。
RepoViewModel是bindRepoViewModel的參數所以也要加入Dagger之中,使用constructor injection:
public class RepoViewModel extends ViewModel {
    ...
    @Inject
    public RepoViewModel(DataModel dataModel) {
        super();
        ...
    }
    ...
}
將ViewModelModule加入Component中,可以用昨天的直接寫在Component方式,也可以用includes將它跟AppModule組在一起:
@Module(includes = ViewModelModule.class)
class AppModule {
    ...
}
修改GithubViewModelFactory:
@Singleton
public class GithubViewModelFactory implements ViewModelProvider.Factory {
    
    private final Map<Class<? extends ViewModel>, Provider<ViewModel>> creators;
    @Inject
    public GithubViewModelFactory(Map<Class<? extends ViewModel>, Provider<ViewModel>> creators) {
        this.creators = creators;
    }
    
    @SuppressWarnings("unchecked")
    @NonNull
    @Override
    public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
        Provider<? extends ViewModel> creator = creators.get(modelClass);
        if (creator == null) {
            for (Map.Entry<Class<? extends ViewModel>, Provider<ViewModel>> entry : creators.entrySet()) {
                if (modelClass.isAssignableFrom(entry.getKey())) {
                    creator = entry.getValue();
                    break;
                }
            }
        }
        if (creator == null) {
            throw new IllegalArgumentException("unknown model class " + modelClass);
        }
        try {
            return (T) creator.get();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
這段貌似有點複雜,我們分解一下慢慢看,首先是:
private final Map<Class<? extends ViewModel>, Provider<ViewModel>> creators;
這就是由@IntoMap產生的Map<Key, Provider<Value>>,其包含了ViewModelModule中宣告的ViewModel資訊,因為我們使用@ViewModelKey所以Key的部分是Class<? extends ViewModel>,而Value的型態是ViewModel。
可以注意的是Value用Provider包起來,Provider表示物件並不於inject的時候就實例化,而是當使用get()呼叫時才實例化一個物件,下一次呼叫get()就再實例化另一個,為什麼這樣用呢?因為我們的ViewModel是隨著View產生且隨著View銷毀,並不像Retrofit是App從頭到尾都用同一個,所以用Provider讓我們每次進入View時可以用get()取得新的ViewModel。官方文件有個範例比較一般inject和Provider的不同。
而這個Map會做為constructor parameter:
@Inject
public GithubViewModelFactory(Map<Class<? extends ViewModel>, Provider<ViewModel>> creators) {
    this.creators = creators;
}
因為Map就是由Dagger產生的,所以同為Dagger管理的GithubViewModelFactory當然可以直接將其作為parameter取得。
接著就是在create(...)利用Map中的Key/Value資訊實例化對應的ViewModel了,首先用creators.get(modelClass)找出以modelClass為Key的Value,creators是前面的那個Map別看錯了。
Provider<? extends ViewModel> creator = creators.get(modelClass);
如果前面找不到Value的話,再用迴圈檢查一次。
if (creator == null) {
    for (Map.Entry<Class<? extends ViewModel>, Provider<ViewModel>> entry : creators.entrySet()) {
    if (modelClass.isAssignableFrom(entry.getKey())) {
        creator = entry.getValue();
        break;
        }
    }
}
迴圈檢查完還是沒有就丟exception報錯了。
if (creator == null) {
    throw new IllegalArgumentException("unknown model class " + modelClass);
}
如果前面已經有找到Value的話,這邊就用get()取得新的ViewModel並回傳,就完成了。
try {
    return (T) creator.get();
} catch (Exception e) {
    throw new RuntimeException(e);
}
以上這些修改完之後,將來要新增ViewModel時只要在ViewModelModule增加程式就好,不用改其它地方,例如新增UserViewModel:
@Module
abstract class ViewModelModule {
    @Binds
    @IntoMap
    @ViewModelKey(RepoViewModel.class)
    abstract ViewModel bindRepoViewModel(RepoViewModel repoViewModel);
    
    @Binds
    @IntoMap
    @ViewModelKey(UserViewModel.class)
    abstract ViewModel bindUserViewModel(UserViewModel userViewModel);
    ...
}
Dagger的部分就到這邊了,個人覺得Dagger真的不好上手,不論是概念或實作都要經過很多思考,不過成功建置之後對將來開發和維護測試都很有幫助,建議可以從相對基本的建立Module及@Provides方式著手,之後再慢慢擴展。文中若有錯誤的地方還請不吝指教。
GitHub source code:
https://github.com/IvanBean/ITBon2018/tree/day12-dagger-viewmodel
Reference:
Inject interfaces without provide methods on Dagger 2